【CDK】Lambda Python で構造化された JSON ログをカスタムフォーマッターにしてみる
はじめに
データ事業本部ビッグデータチームのyosh-kです。
今回はLambda PythonでJson logを使用時にカスタムフォーマッターを実装してみたいと思います。
前提
実装コード
今回の実装コードについては、Github上に格納してあるのでご確認いただければと思います。
@41_lambda_logging_json_with_cdk % tree
.
├── README.md
├── cdk
│ ├── bin
│ │ └── app.ts
│ ├── cdk.json
│ ├── jest.config.js
│ ├── lib
│ │ └── lambda-logging-json-stack.ts
│ ├── package-lock.json
│ ├── package.json
│ ├── parameter.ts
│ ├── test
│ │ └── app.test.ts
│ └── tsconfig.json
└── resources
└── lambda
├── lambda_handler_custom.py
├── lambda_handler_default.py
└── lib
├── check_log_custom.py
├── check_log_default.py
└── get_logger.py
8 directories, 13 files
なぜログ形式としてJSONを選択するのか
デフォルトでは、Lambda関数はプレーンテキスト形式、つまり非構造化ログ形式でログを出力していました。これにより、ログのクエリやフィルタリングが難しくなってしまう場合もありました。ログ出力を JSON キー値のペアとしてキャプチャすると、関数のデバッグ時に検索やフィルタリングが簡単になります。JSON 形式のログでは、ログにタグやコンテキスト情報を追加することもできます。これにより、大量のログデータの自動分析が可能になります。開発ワークフローがプレーンテキストで Lambda ログを使用する既存のツールに依存していない限り、ログ形式として JSON を選択することをお勧めします。その中で今回カスタムフォーマッターを作成する意図としては、linenoやfuncNameといった情報がデフォルトで出力されないので追加したいと感じたためです。以下は参考記事になります。
実装
bin/app.ts
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { LambdaStack } from "../lib/lambda-logging-json-stack";
import { devParameter, prodParameter } from "../parameter";
const app = new cdk.App();
const envKey = app.node.tryGetContext("environment") ?? "dev"; // default: dev
let parameter;
if (envKey === "dev") {
parameter = devParameter;
} else {
parameter = prodParameter;
}
new LambdaStack(app, `CMKasamaLambdaJson${envKey.toUpperCase()}`, {
description: `${parameter.projectName}-${parameter.envName}-test-tag`,
env: {
account: parameter.env?.account || process.env.CDK_DEFAULT_ACCOUNT,
region: parameter.env?.region || process.env.CDK_DEFAULT_REGION,
},
tags: {
Repository: `${parameter.projectName}-${parameter.envName}-test-tag`,
Environment: parameter.envName,
},
projectName: parameter.projectName,
envName: parameter.envName,
app_log_level: parameter.app_log_level,
});
app.tsではparatemer.tsで指定したパラメータをもとにスタックを定義しています。
lib/lambda-logging-json-stack.ts
import { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
export interface LambdaStackProps extends cdk.StackProps {
envName: string;
projectName: string;
app_log_level: string;
}
export class LambdaStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: LambdaStackProps) {
super(scope, id, props);
const lambdaRole = new iam.Role(this, "LambdaExecutionRole", {
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
),
],
});
const customLambdaName = `${props.projectName}-${props.envName}-json-custom-log-test-handler`;
const defaultLambdaName = `${props.projectName}-${props.envName}-json-default-log-test-handler`;
new lambda.Function(this, "JsonCustomLogTestHandler", {
functionName: customLambdaName,
runtime: lambda.Runtime.PYTHON_3_12,
code: lambda.Code.fromAsset("../resources/lambda"),
handler: "lambda_handler_custom.lambda_handler", // ハンドラー名を修正
memorySize: 512,
timeout: cdk.Duration.seconds(900),
role: lambdaRole,
environment: {},
architecture: lambda.Architecture.ARM_64,
loggingFormat: lambda.LoggingFormat.JSON,
applicationLogLevelV2:
lambda.ApplicationLogLevel[
props.app_log_level as keyof typeof lambda.ApplicationLogLevel
],
});
new lambda.Function(this, "JsonDefaultLogTestHandler", {
functionName: defaultLambdaName,
runtime: lambda.Runtime.PYTHON_3_12,
code: lambda.Code.fromAsset("../resources/lambda"),
handler: "lambda_handler_default.lambda_handler", // ハンドラー名を修正
memorySize: 512,
timeout: cdk.Duration.seconds(900),
role: lambdaRole,
environment: {},
architecture: lambda.Architecture.ARM_64,
loggingFormat: lambda.LoggingFormat.JSON,
applicationLogLevelV2:
lambda.ApplicationLogLevel[
props.app_log_level as keyof typeof lambda.ApplicationLogLevel
],
});
}
}
lambdaの設定としてloggingFormat
にJSONを指定し、applicationLogLevelV2
にてログレベルを指定します。
lambda.ApplicationLogLevel
は AWS CDK の Lambda モジュールで定義されている列挙型(enum)です。これは、ログレベルを指定するために使用されます。as keyof typeof lambda.ApplicationLogLevel
は TypeScript の型アサーションです。これにより、props.app_log_level
が lambda.ApplicationLogLevel
の有効なキー("INFO" or "WARN" or "ERROR" or "DEBUG" or "TRACE" or "FATAL")であることを保証しています。
lambda.ApplicationLogLevel[]
は、文字列のログレベル(例: "INFO")を対応する lambda.ApplicationLogLevel
のenum(例: lambda.ApplicationLogLevel.INFO)に変換しています。
parameter.ts
import { Environment } from "aws-cdk-lib";
// Parameters for Application
export interface AppParameter {
env: Environment;
envName: string;
projectName: string;
app_log_level: string;
}
// Example
export const devParameter: AppParameter = {
envName: "dev",
projectName: "cm-kasama",
env: {},
app_log_level: "DEBUG",
// env: { account: "xxxxxx", region: "ap-northeast-1" },
};
export const prodParameter: AppParameter = {
envName: "prod",
projectName: "cm-kasama",
env: {},
app_log_level: "INFO",
// env: { account: "xxxxxx", region: "ap-northeast-1" },
};
devとprodでlog levelを変更したい場合はこのファイルの設定を修正します。
resources/lambda/lambda_handler_custom.py
import json
from lib.get_logger import setup_logger
from lib.check_log_custom import check_log_test
logger = setup_logger()
def lambda_handler(event, context):
# 各ログレベルでメッセージを出力
logger.debug("This is a debug message")
logger.info("This is an info message")
logger.warning("This is a warning message")
logger.error("This is an error message")
logger.critical("This is a critical message")
# エラーをシミュレート
try:
1 / 0
except ZeroDivisionError:
logger.exception("An exception occurred")
check_log_test()
return {
"statusCode": 200,
"body": json.dumps("Logging configuration inspected and tested!"),
}
get_logger
ファイルでlogのカスタムフォーマッターを定義した関数を呼び出し、簡単にlog level毎の出力を確認する実装としています。
resources/lambda/lambda_handler_default.py
import logging
import json
from lib.check_log_default import check_log_test
logger = logging.getLogger()
def lambda_handler(event, context):
# 各ログレベルでテストメッセージを出力
logging.debug("This is a debug message")
logging.info("This is an info message")
logging.warning("This is a warning message")
logging.error("This is an error message")
logging.critical("This is a critical message")
# エラーをシミュレートしてスタックトレースを表示
try:
1 / 0
except ZeroDivisionError:
logging.exception("An exception occurred")
check_log_test()
return {
"statusCode": 200,
"body": json.dumps("Logging configuration inspected and tested!"),
}
こちらはデフォルトのフォーマッターを用いた実装としています。
resources/lambda/lib/check_log_custom.py
from lib.get_logger import setup_logger
logger = setup_logger()
def check_log_test():
logger.debug("debug")
logger.info("info")
logger.error("error")
logger.critical("critical")
カスタムフォーマッターで使用する別ファイルから呼び出した際のlog出力を確認するための処理になります。
resources/lambda/lib/check_log_default.py
import logging
logger = logging.getLogger()
def check_log_test():
logger.debug("debug")
logger.info("info")
logger.error("error")
logger.critical("critical")
デフォルトフォーマッターで使用する別ファイルから呼び出した際のlog出力を確認するための処理になります。
resources/lambda/lib/get_logger.py
import logging
import json
import traceback
class CustomJsonFormatter(logging.Formatter):
def format(self, record):
log_entry = {
"timestamp": self.formatTime(record),
"level": record.levelname,
"message": record.getMessage(),
"logger": record.name,
"requestId": getattr(record, "aws_request_id", None),
"funcName": record.funcName,
"lineno": record.lineno,
}
if record.exc_info:
log_entry.update(
{
"stackTrace": traceback.format_exception(*record.exc_info),
"errorType": record.exc_info[0].__name__,
"errorMessage": str(record.exc_info[1]),
"location": f"{record.pathname}:{record.funcName}:{record.lineno}",
}
)
return json.dumps(log_entry)
def setup_logger():
logger = logging.getLogger()
# Lambdaのデフォルトハンドラーを取得し、カスタムフォーマッターを適用
handler = logger.handlers[0]
handler.setFormatter(CustomJsonFormatter())
return logger
このコードは、カスタムJsonフォーマッターを実装しています。CustomJsonFormatter クラスは、ログレコードを構造化された JSON 形式に変換し、timestamp
、level
、message
、logger
、requestId
、funcName
、lineno
などの重要な情報を含めます。デフォルトのフォーマッター設定ではないfuncName
やlineno
を追加しています。例外時は、record.exc_info
がTrueとなり、log情報を追加しています。get_logger()
関数は、このカスタムフォーマッターを使用して既存のハンドラーに適用しています。logging.LogRecord
というPythonの組み込みクラスを直接修正することは、予期しない副作用を引き起こす可能性があるため、今回はしていません。他の方法があれば別途追記したいと思います。
デプロイ
package.jsonがあるディレクトリで依存関係をインストールします。
npm install
次にcdk.jsonがあるディレクトリで、CDKで定義されたリソースのコードをAWS CloudFormationテンプレートに合成(変換)するプロセスを実行します。
npx cdk synth --profile <YOUR_AWS_PROFILE>
同じくcdk.jsonがあるディレクトリでデプロイコマンドを実行します。--all
はCDKアプリケーションに含まれる全てのスタックをデプロイするためのオプション、--require-approval never
はセキュリティ的に敏感な変更やIAMリソースの変更を含むデプロイメント時の承認を求めるダイアログ表示を完全にスキップします。neverは、どんな変更でも事前確認なしにデプロイすることを意味します。今回は検証用なので指定していますが、慎重にデプロイする場合は必要のないオプションになるかもしれません。-c
でenvironmentを指定し、環境に合わせたデプロイを行います。
npx cdk deploy --all --require-approval never -c environment=dev --profile <YOUR_AWWS_PROFILE>
実行結果
lambda_handler_default.py
Lambdaをテスト実行した際のCloudWatch Logsになります。linenoやfuncNameがないので、どの箇所でエラーになったのかがぱっと見でわかりづらいことがあると感じます。
lambda_handler_custom.py
同じくLambdaをテスト実行した際のCloudWatch Logsになります。こちらはlinenoとfuncNameを追加したので、どの箇所でエラーになっているか分かり易くなっていると思います。
最後に
カスタムフォーマッターの場合は、デフォルトフォーマッターを上書きしてしまうので、デフォルトが変わった際の対応が必要となります。デフォルトフォーマッターでも詳細なデバッグ情報が含まれていないというデメリットもありますので、プロジェクトのニーズと運用効率のバランスを考慮して選択すれば良いと考えています。